Fix hollow-response loop in claude-cli backend (re-targeted to v8, clean diff, with tests) - #1063
Merged
safishamsi merged 1 commit intoMay 29, 2026
Conversation
…nflict
Three compounding bugs caused ~30-50% of semantic chunks to come back
as 'hollow responses' on the claude-cli backend, triggering adaptive
bisection that doubled or tripled the number of subprocess calls.
Root causes
-----------
1. _parse_llm_json only stripped markdown fences when raw.startswith('```').
Claude frequently prepends a short preamble before the fence
('Here are the extracted entities:\n\n```json\n{...}```'), making
the check fail. json.loads then drops the chunk. Each bisected half
may exhibit the same failure, so cost compounds.
2. _call_claude_cli used --append-system-prompt, which layers graphify's
extraction prompt on top of Claude Code's default interactive-agent
prompt ('use markdown formatting', 'output text to communicate with
the user'). The conflicting instructions explain ~50% of the
preambles and fences from (1). Switching to --system-prompt (replace)
eliminates the conflict at the source.
3. claude-cli defaults to Opus, which is overkill for the structured
JSON extraction graphify performs. New GRAPHIFY_CLAUDE_CLI_MODEL env
var lets users opt into haiku / sonnet for big builds. Default
behaviour unchanged when the env var is unset.
Fix
---
- Robust _parse_llm_json: strips fences regardless of position, with a
balanced-brace fallback that scans for the first complete JSON object
in the response. Handles preambles, trailing prose, prose-wrapped
JSON without fences. Diagnostic log on terminal failure includes the
first 200 chars of the response.
- _call_claude_cli switches to --system-prompt.
- _call_claude_cli respects GRAPHIFY_CLAUDE_CLI_MODEL when set.
Tests (tests/test_llm_parser.py)
--------------------------------
- The four PR-body failure modes: preamble+fence, prose+JSON, raw JSON,
total refusal.
- Bonus: uppercase fence tag, unclosed fence, empty response.
- argv shape: --system-prompt present, --append-system-prompt absent.
- argv shape: --model added iff GRAPHIFY_CLAUDE_CLI_MODEL is set.
19/19 tests pass (9 pre-existing in test_claude_cli_backend.py +
10 new). Verified end-to-end on a 800-file repo: 0 hollow responses
after, vs ~30-50% before; output tokens -93%; wall time 44 min -> 4 min.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Re-opens #1062 with the maintainer feedback applied:
v8(fresh branch offupstream/v8, no stalemainnoise)graphify/llm.py(1 file changed in the source)tests/test_llm_parser.pycovering the four parser failure modes + argv-shape assertionsClosing #1062. Same scope, clean diff.
Problem
graphify extract --backend claude-cliagainst a multi-modal corpus comes back with ~30-50% of semantic chunks flagged as hollow responses and triggers adaptive bisection. On an 800-file repo this turned a ~15 min run into ~44 min and consumed 2-3× moreclaudesubscription quota than necessary.Symptoms in the log:
The hollow-detection path works as designed — the issue is what causes Claude to return content that fails
json.loads.Root cause (two compounding bugs)
1.
_parse_llm_jsononly strips fences at offset 0Claude (and most chat models) frequently prepends a short preamble before the JSON:
raw.startswith("```")returnsFalse, the fence-stripping is skipped entirely,json.loadsfails on the preamble text, the chunk is dropped, the hollow detector re-routes it to bisection. Each bisected half is anotherclaude -pcall that may exhibit the same failure. Cost compounds.2.
_call_claude_cliuses--append-system-prompt--append-system-promptadds graphify's extraction prompt on top of Claude Code's default interactive-agent system prompt ("use markdown formatting", "output text to communicate with the user"). These conflict with graphify's "return raw JSON only" instruction, and the default prompt wins about half the time — producing the preambles and markdown fences from issue 1.The
claudeCLI exposes--system-prompt(replace) since at least 2.1.x, which is the right primitive for a headless extraction backend.Fix
Three complementary changes in
graphify/llm.py:1. Robust JSON extraction in
_parse_llm_json— strips fences regardless of position, with a balanced-brace fallback that scans for the first complete{...}object anywhere in the response. Handles preambles, trailing prose, and prose-wrapped JSON without fences. Diagnostic log on terminal failure includes the first 200 chars so future format drift is debuggable.2. Switch
claude-clito--system-prompt— eliminates the conflict at the source. Claude receives only graphify's extraction prompt and returns clean JSON on the first call. Side benefit: cache-creation tokens per call drop ~19% (47k vs 58k in my measurements) because Claude Code's default system prompt is no longer materialized.3.
GRAPHIFY_CLAUDE_CLI_MODELenv var — claude-cli defaults to Opus, which is overkill for the structured JSON extraction graphify performs. SettingGRAPHIFY_CLAUDE_CLI_MODEL=haikulets users cut quota usage 3-5× for the semantic pass. Default behaviour unchanged when the env var is unset.The three fixes are complementary: 2 dramatically reduces the rate of malformed responses; 1 keeps graphify robust against the residual cases (soft refusals, model confusion) and benefits every other backend too; 3 unlocks cheaper builds, which is only safe because 1+2 make Haiku's more frequent markdown-wrapping recoverable.
Tests (
tests/test_llm_parser.py, 10 cases)Parser:
test_preamble_then_fence_is_parsed— the primary bugtest_prose_wrapped_json_without_fence_is_parsed— balanced-brace fallbacktest_raw_json_still_works— regression check on the happy pathtest_total_refusal_returns_empty_fragment— graceful degradationtest_fence_with_uppercase_language_tag—```JSONtest_fence_without_closing_backticks— truncation casetest_empty_response_returns_empty_fragmentargv shape (mocked subprocess, same pattern as the existing
test_claude_cli_backend.py):test_uses_system_prompt_not_appendtest_model_env_var_adds_model_flagtest_no_model_flag_when_env_var_unsetEvidence
Test run on a 43-file
modes/directory (Markdown docs):Validated end-to-end on an 800-file repo (mixed code + docs):
GRAPHIFY_CLAUDE_CLI_MODEL=haikualso validated on a small corpus: graph structure identical (118 nodes / 193 edges vs Opus baseline 118 / 192), output tokens -82%.Trade-offs
--system-promptreplaces Claude Code's default prompt entirely. For the-pheadless extraction use case this is desirable. Subscription auth is unaffected (verified).json.loads(stripped)and fence-stripping fail — no perf impact on the common path.--no-session-persistenceis unchanged. A follow-up could explore session reuse to reclaim more cache budget, but that's orthogonal.